feat(scripts): enforce job-level permissions in the workflow permissions gate - #2553
Conversation
…ons gate Test-WorkflowPermissions.ps1 matched only `(?m)^permissions:` and scored the repository 100% while jobs across six workflows carried no permissions block of their own. The gate did not enforce the job-level convention it documented. Parse each workflow with ConvertFrom-Yaml, enumerate jobs from the parsed object graph, and apply a four-state classification: absent workflow + absent job -> violation (inherits repo/org default) empty workflow + absent job -> pass (zero scopes available to inherit) populated workflow + absent job -> violation (implicit, unauditable grant) any workflow + present job -> pass The empty-block pass is the distinction third-party scanners miss; it is why alert #348 against pr-review.lock.yml was a false positive. Remediate the single offender: beval.yml job `evaluate` now declares `contents: read`, which it already inherited. Effective permissions are unchanged; no job's privilege was widened and no generated lock file was edited. Supporting changes: - Add MissingJobPermissions to the ViolationType ValidateSet, additively - Report file-level and job-level metrics separately so compliance-score and violation-count cannot contradict each other - Declare a SARIF rule per violation type, floor startLine at 1, and normalize paths to forward slashes - Correct enforcement claims in workflows.instructions.md, security-model.md, and scripts/security/README.md to describe the rule actually enforced Tests cover every matrix row, null/flow/scalar permissions shapes, four-space job indentation, a lock-file-shaped regression, nested step-level permissions keys, SARIF contract validity, and malformed-workflow degradation. The workflow-with-permissions fixture was in the violating state and gained an explicit job block rather than having its assertion relaxed.
…ibution
Implements review findings RV-001, RV-002, and RV-003 from the job-level
workflow permissions gate review.
RV-001: The documentation added alongside the gate asserted that under a
workflow-level `permissions: {}` a job "can hold no scope regardless of whether
it declares a block". That is false. GitHub adjusts permissions first at the
workflow level and then at the job level, and a job-level block configures a
different set for that job. An empty workflow-level block is a default, not a
ceiling, so a job declaring `contents: write` beneath it does receive that
scope. All affected surfaces now state the accurate reason: a job that declares
no block inherits an empty set and therefore holds nothing.
The gate's four-state verdicts are unchanged; only the stated rationale was
wrong.
RV-002: Add a regression guard asserting no violation path contains a backslash.
The forward-slash normalization had no test, and because the SARIF upload step
is continue-on-error, an unnormalized artifactLocation.uri would fail silently.
RV-003: Constrain job line attribution to the region after the top-level `jobs:`
key. The previous whole-file search could report a same-named key declared
earlier, such as a workflow_call output sharing a job name. Reporting precision
only; job identity has always come from the parsed object graph.
…it job metrics Addresses findings from an independent review pass over the branch diff. workflow-permissions-scan.yml had no PowerShell-Yaml install step. The gate previously used a regex and had no module dependency; it now hard-requires PowerShell-Yaml 0.4.7 via #Requires and Import-Module -ErrorAction Stop. That workflow runs on ubuntu-latest with only a checkout step, so the gate would have failed on every PR. Both upload steps are continue-on-error, so the failure could have been quiet. Adds the setup-ps-modules composite action, matching the pattern pr-validation.yml already uses for the same reason. JobsWithPermissions credited jobs that declare no block. Under an empty workflow-level block the check returns early, so the job-level violation count is always zero and every job was counted as declaring permissions. The gate reported "193/193 declare their own permissions" while pr-review.lock.yml job pre_activation has no block at all. Split into two honest metrics: JobsPassing 193 jobs that pass the job-level check JobsDeclaringOwnBlock 188 jobs that actually declare a block The remaining five pass by inheriting an empty workflow-level block. Log lines and step-summary rows now distinguish the two, and a test pins the difference. Also corrects a fixture header comment that repeated the inheritance claim fixed in the previous commit, and reworks success messages that said jobs "declare permissions" when some pass by inheritance.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2553 +/- ##
==========================================
- Coverage 82.75% 82.53% -0.23%
==========================================
Files 155 155
Lines 20963 21123 +160
Branches 13 13
==========================================
+ Hits 17348 17433 +85
- Misses 3613 3688 +75
Partials 2 2
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Eval Execution✅ Status: Passed
No changed AI artifacts required evaluation. |
jkim323
left a comment
There was a problem hiding this comment.
Left a couple of things to be looked at!
- treat YAML parse failures as violations under -FailOnViolation - constrain job declaration line lookup to direct jobs children ✅ - Generated by Copilot
katriendg
left a comment
There was a problem hiding this comment.
Working nicely! Ready to merge.
Wanted to share what my reviewer called out:
The PR description's "Known behavior: unparseable workflows do not block" section states:
"...it produces no violation, so -FailOnViolation does not trip on it."
I tested this directly against a malformed workflow fixture: the script does emit a MissingPermissions violation for parse failures and does trip -FailOnViolation (confirmed via $LASTEXITCODE and the PR's own test Should report an unparseable workflow without counting it as compliant, which asserts $exitCode | Should -Be 1 and passes).
This is a fail-closed behavior, not fail-open as the description claims. It's the safer of the two postures and consistent with the code and tests, so it's not a functional defect — but the description narrative appears to predate the branch's last commit (cd04267 feat(scripts): fail closed on parse errors and tighten job line matching), which changed this behavior without the PR body being updated to match. Worth a comment asking the author to correct/remove that paragraph before merge so reviewers aren't misled about the actual fail-closed guarantee.
|
Thanks for flagging this — fixed. I updated the PR description to remove the stale fail-open wording and replaced it with the current fail-closed behavior
is called out\n\nThe updated section is now under Known behavior: unparseable workflows fail closed in the PR body. |
Description
scripts/security/Test-WorkflowPermissions.ps1checked one thing: whether a file contained a top-levelpermissions:block, matched with(?m)^permissions:against raw text. It had no job awareness at all. It scored this repository 100% (64/64) while jobs across six workflows carried no declaration of their own.The convention in
.github/instructions/workflows.instructions.mdrequires job-level declarations, anddocs/security/security-model.mdasserted that all workflows already had them. Neither was enforced, and the second was not true.This change taught the gate to parse each workflow with
ConvertFrom-Yaml, enumerate its jobs from the parsed object graph, and apply a four-state classification.permissions: {}Why row 3 is the one that matters
A measurement of all 65 workflows found 0 absent, 7 empty, 58 populated top-level blocks. A rule built only on row 1 would have been unreachable ΓÇö it would have detected the empty set and shipped as a no-op. Row 3 gives the gate a reachable failure state, and it found exactly one on this tree:
.github/workflows/beval.ymljobevaluate.That job now declares
contents: readΓÇö which it already inherited. The effective permission set is identical before and after. No job's privilege was widened anywhere in this change, and no generatedgh-awlock file was edited.Why row 2 is a deliberate pass
An empty workflow-level block grants nothing, so a job beneath it that declares no block inherits an empty set and holds no scope. This is precisely the distinction the third-party Poutine scanner cannot make ΓÇö its
utils.empty()helper treats an empty map identically to an absent one ΓÇö which is why it raised code scanning alert #348 against.github/workflows/pr-review.lock.yml, a file that implements Poutine's own documented remediation. That alert was dismissed as a false positive. This change adds no.poutine.ymlsuppression; a precise local gate was preferred over silencing a scanner.Worth stating plainly: an empty workflow-level block is a default, not a ceiling. A job that declares its own block beneath one still receives what it declares, because job-level permissions replace the workflow-level set rather than being capped by it.
Supporting changes
SecurityClasses.psm1gains aMissingJobPermissionsvalue in theViolationTypeValidateSet, which would otherwise throw on the first job-level finding. Additive only.compliance-scoreandviolation-countcannot contradict each other under a mixed model.ruleId, floorsstartLineat 1, and normalizes paths to forward slashes. The previous version hardcoded a singleruleIdandstartLine = 1; since the upload step iscontinue-on-error, a malformed document would have failed silently.workflow-permissions-scan.ymlgains thesetup-ps-modulesstep. The script now depends on PowerShell-Yaml, which is not preinstalled onubuntu-latest.Reviewer note on
compliance-scoreThe score is now denominated in workflow-level plus job-level checks ΓÇö 257 checks on this tree instead of 64 files. It remains a 0ΓÇô100 percentage, so the downstream contract shape in
workflow-permissions-scan.ymlandpr-validation.ymlis unchanged, but historical scores are not directly comparable.Two job metrics are published deliberately: 193/193 jobs pass the job-level check, but only 188/193 declare their own block. The remaining five pass by inheriting an empty workflow-level block. Collapsing these into one number would have claimed every job declares permissions, which is false.
Related Issue(s)
Closes #2541
Split out from #2527, which covers the full Poutine scan of
mainand remains open independently.Type of Change
Select all that apply:
Code & Documentation:
Infrastructure & Configuration:
AI Artifacts:
hve-builderand addressed all actionable findings.github/instructions/*.instructions.md).github/prompts/*.prompt.md).github/agents/*.agent.md).github/skills/*/SKILL.md).github/hooks/*/*.json)evals/)Other:
.ps1,.sh,.py)Sample Prompts (for AI Artifact Contributions)
The only AI artifact touched is
.github/instructions/workflows.instructions.md, which is passive guidance applied by glob rather than an invocable artifact. It gains the per-job permissions requirement and the four-state classification table so the documented rule matches the enforced one.User Request:
Not directly invocable. The instruction file applies automatically when editing files under
.github/workflows/.Execution Flow:
An agent editing a workflow file receives the permissions convention as context, including the requirement that every job declare its own
permissions:block whenever the workflow-level block grants any scope, and thepermissions: {}exception.Output Artifacts:
None. The file contributes guidance only.
Success Indicators:
A workflow authored under this guidance passes
npm run lint:permissionswithout aMissingJobPermissionsfinding.For detailed contribution requirements, see:
Testing
Automated validation
npm run lint:permissions(pre-remediation)beval.ymljobevaluate, exit 1npm run lint:permissions(post-remediation)npm run test:ps -- -TestPath "scripts/tests/security/"npm run lint:psnpm run lint:yamlnpm run lint:mdnpm run validate:localnpm run validate:docsdocs:lintbecausedocs/docusaurusdependencies are not installed here. Unrelated to this change; no Docusaurus files are touched.The pre-remediation failure is deliberate evidence: it demonstrates the gate has a reachable failure state, which the original top-level-only rule did not.
Test coverage added
A case per matrix row; null, flow-style, and scalar
permissionsvalue shapes; four-space job indentation; a lock-file-shaped regression asserting row 2 passes; a nested step-levelpermissionskey that must not count as a job declaration; SARIFruleIdresolution andstartLinefloor; malformed-workflow degradation; a no-backslash assertion on violation paths; a job-line-attribution regression using aworkflow_calloutput that shares a job name; and a metrics case pinning that a job passing by inheritance is not counted as declaring a block.The existing
workflow-with-permissions.ymlfixture was itself in the row-3 state. Rather than relax its passing assertion, the fixture was brought into compliance so the assertion stays meaningful. No existing fixture or assertion was deleted.Security analysis
scripts/security/ps-module-versions.jsonand installed via the cachedsetup-ps-modulescomposite action.Manual testing
Manual testing was not performed. All verification was automated, plus diff-level inspection of the privilege-neutrality claim.
Checklist
Required Checks
AI Artifact Contributions
hve-builderreview mode to review contributionhve-builderreviewRequired Local Checks
The following local-safe validation commands must pass before merging:
npm run validate:localnpm run validate:docs(N/A ΓÇö fails locally atdocs:lintbecausedocs/docusaurusdependencies are not installed in this environment; unrelated to this change, which touches no Docusaurus files. See Pin eslint to 9.39.4 in docs/docusaurus and regenerate lockfile (PR #2519) #2534.)npm run spell-checknpm run lint:md-linksSecurity Considerations
Additional Notes
An independent review pass caught a CI-breaking defect
A subagent review of the diff found that
workflow-permissions-scan.ymlhad no PowerShell-Yaml install step. The original script was pure regex with no module dependency; the new one hard-requires the module via both#RequiresandImport-Module -ErrorAction Stop. That workflow runs onubuntu-latestwith only a checkout step, so the gate would have failed on every PR ΓÇö and because both upload steps arecontinue-on-error: true, it could have failed quietly. The same review found the job metric was crediting jobs that declare no block. Both are fixed here, with the metric split into two honest numbers.Known behavior: unparseable workflows fail closed
A workflow that fails to parse now emits a MissingPermissions violation and error annotation, and -FailOnViolation exits non-zero. This matches the current implementation and tests (including the unparseable-workflow integration test asserting exit code 1).
lint:yaml (actionlint) remains a complementary guardrail in the PR gate, so malformed workflows are covered both by YAML validation and by the permissions gate itself.
Backwards compatibility
The
MissingJobPermissionsValidateSet addition is purely additive.compliance-scoreandviolation-countkeep their shape and type; only the score denominator changes, which is called out above for anyone tracking the number over time.